Refactor common code from google.oauth2.flow to google.oauth2.oauthlib (#106)
diff --git a/docs/reference/google.oauth2.oauthlib.rst b/docs/reference/google.oauth2.oauthlib.rst new file mode 100644 index 0000000..c687490 --- /dev/null +++ b/docs/reference/google.oauth2.oauthlib.rst
@@ -0,0 +1,7 @@ +google.oauth2.oauthlib module +============================= + +.. automodule:: google.oauth2.oauthlib + :members: + :inherited-members: + :show-inheritance:
diff --git a/docs/reference/google.oauth2.rst b/docs/reference/google.oauth2.rst index 5dc2406..4dd1ebd 100644 --- a/docs/reference/google.oauth2.rst +++ b/docs/reference/google.oauth2.rst
@@ -14,5 +14,6 @@ google.oauth2.credentials google.oauth2.flow google.oauth2.id_token + google.oauth2.oauthlib google.oauth2.service_account
diff --git a/google/oauth2/flow.py b/google/oauth2/flow.py index a4dcfca..e70ee3d 100644 --- a/google/oauth2/flow.py +++ b/google/oauth2/flow.py
@@ -55,12 +55,9 @@ import json -import requests_oauthlib - import google.auth.transport.requests import google.oauth2.credentials - -_REQUIRED_CONFIG_KEYS = frozenset(('auth_uri', 'token_uri', 'client_id')) +import google.oauth2.oauthlib class Flow(object): @@ -82,9 +79,33 @@ https://console.developers.google.com/apis/credentials """ - def __init__(self, client_config, scopes, **kwargs): + def __init__(self, oauth2session, client_type, client_config): """ Args: + oauth2session (requests_oauthlib.OAuth2Session): + The OAuth 2.0 session from ``requests-oauthlib``. + client_type (str): The client type, either ``web`` or + ``installed``. + client_config (Mapping[str, Any]): The client + configuration in the Google `client secrets`_ format. + + .. _client secrets: + https://developers.google.com/api-client-library/python/guide + /aaa_client_secrets + """ + self.client_type = client_type + """str: The client type, either ``'web'`` or ``'installed'``""" + self.client_config = client_config[client_type] + """Mapping[str, Any]: The OAuth 2.0 client configuration.""" + self.oauth2session = oauth2session + """requests_oauthlib.OAuth2Session: The OAuth 2.0 session.""" + + @classmethod + def from_client_config(cls, client_config, scopes, **kwargs): + """Creates a :class:`requests_oauthlib.OAuth2Session` from client + configuration loaded from a Google-format client secrets file. + + Args: client_config (Mapping[str, Any]): The client configuration in the Google `client secrets`_ format. scopes (Sequence[str]): The list of scopes to request during the @@ -92,6 +113,9 @@ kwargs: Any additional parameters passed to :class:`requests_oauthlib.OAuth2Session` + Returns: + Flow: The constructed Flow instance. + Raises: ValueError: If the client configuration is not in the correct format. @@ -100,29 +124,19 @@ https://developers.google.com/api-client-library/python/guide /aaa_client_secrets """ - self.client_config = None - """Mapping[str, Any]: The OAuth 2.0 client configuration.""" - self.client_type = None - """str: The client type, either ``'web'`` or ``'installed'``""" - if 'web' in client_config: - self.client_config = client_config['web'] - self.client_type = 'web' + client_type = 'web' elif 'installed' in client_config: - self.client_config = client_config['installed'] - self.client_type = 'installed' + client_type = 'installed' else: raise ValueError( 'Client secrets must be for a web or installed app.') - if not _REQUIRED_CONFIG_KEYS.issubset(self.client_config.keys()): - raise ValueError('Client secrets is not in the correct format.') + session, client_config = ( + google.oauth2.oauthlib.session_from_client_config( + client_config, scopes, **kwargs)) - self.oauth2session = requests_oauthlib.OAuth2Session( - client_id=self.client_config['client_id'], - scope=scopes, - **kwargs) - """requests_oauthlib.OAuth2Session: The OAuth 2.0 session.""" + return cls(session, client_type, client_config) @classmethod def from_client_secrets_file(cls, client_secrets_file, scopes, **kwargs): @@ -142,7 +156,7 @@ with open(client_secrets_file, 'r') as json_file: client_config = json.load(json_file) - return cls(client_config, scopes=scopes, **kwargs) + return cls.from_client_config(client_config, scopes=scopes, **kwargs) @property def redirect_uri(self): @@ -226,18 +240,8 @@ Raises: ValueError: If there is no access token in the session. """ - if not self.oauth2session.token: - raise ValueError( - 'There is no access token for this session, did you call ' - 'fetch_token?') - - return google.oauth2.credentials.Credentials( - self.oauth2session.token['access_token'], - refresh_token=self.oauth2session.token['refresh_token'], - token_uri=self.client_config['token_uri'], - client_id=self.client_config['client_id'], - client_secret=self.client_config['client_secret'], - scopes=self.oauth2session.scope) + return google.oauth2.oauthlib.credentials_from_session( + self.oauth2session, self.client_config) def authorized_session(self): """Returns a :class:`requests.Session` authorized with credentials.
diff --git a/google/oauth2/oauthlib.py b/google/oauth2/oauthlib.py new file mode 100644 index 0000000..8f5c105 --- /dev/null +++ b/google/oauth2/oauthlib.py
@@ -0,0 +1,142 @@ +# Copyright 2017 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Integration with oauthlib + +.. warning:: + This module is experimental and is subject to change signficantly + within major version releases. + +This module provides helpers for integrating with `requests-oauthlib`_. +Typically, you'll want to use the higher-level helpers in +:mod:`google.oauth2.flow`. + +.. _requests-oauthlib: http://requests-oauthlib.readthedocs.io/en/stable/ +""" + +import json + +import requests_oauthlib + +import google.oauth2.credentials + +_REQUIRED_CONFIG_KEYS = frozenset(('auth_uri', 'token_uri', 'client_id')) + + +def session_from_client_config(client_config, scopes, **kwargs): + """Creates a :class:`requests_oauthlib.OAuth2Session` from client + configuration loaded from a Google-format client secrets file. + + Args: + client_config (Mapping[str, Any]): The client + configuration in the Google `client secrets`_ format. + scopes (Sequence[str]): The list of scopes to request during the + flow. + kwargs: Any additional parameters passed to + :class:`requests_oauthlib.OAuth2Session` + + Raises: + ValueError: If the client configuration is not in the correct + format. + + Returns: + Tuple[requests_oauthlib.OAuth2Session, Mapping[str, Any]]: The new + oauthlib session and the validated client configuration. + + .. _client secrets: + https://developers.google.com/api-client-library/python/guide + /aaa_client_secrets + """ + + if 'web' in client_config: + config = client_config['web'] + elif 'installed' in client_config: + config = client_config['installed'] + else: + raise ValueError( + 'Client secrets must be for a web or installed app.') + + if not _REQUIRED_CONFIG_KEYS.issubset(config.keys()): + raise ValueError('Client secrets is not in the correct format.') + + session = requests_oauthlib.OAuth2Session( + client_id=config['client_id'], + scope=scopes, + **kwargs) + + return session, client_config + + +def session_from_client_secrets_file(client_secrets_file, scopes, **kwargs): + """Creates a :class:`requests_oauthlib.OAuth2Session` instance from a + Google-format client secrets file. + + Args: + client_secrets_file (str): The path to the `client secrets`_ .json + file. + scopes (Sequence[str]): The list of scopes to request during the + flow. + kwargs: Any additional parameters passed to + :class:`requests_oauthlib.OAuth2Session` + + Returns: + Tuple[requests_oauthlib.OAuth2Session, Mapping[str, Any]]: The new + oauthlib session and the validated client configuration. + + .. _client secrets: + https://developers.google.com/api-client-library/python/guide + /aaa_client_secrets + """ + with open(client_secrets_file, 'r') as json_file: + client_config = json.load(json_file) + + return session_from_client_config(client_config, scopes, **kwargs) + + +def credentials_from_session(session, client_config=None): + """Creates :class:`google.oauth2.credentials.Credentials` from a + :class:`requests_oauthlib.OAuth2Session`. + + :meth:`fetch_token` must be called on the session before before calling + this. This uses the session's auth token and the provided client + configuration to create :class:`google.oauth2.credentials.Credentials`. + This allows you to use the credentials from the session with Google + API client libraries. + + Args: + session (requests_oauthlib.OAuth2Session): The OAuth 2.0 session. + client_config (Mapping[str, Any]): The subset of the client + configuration to use. For example, if you have a web client + you would pass in `client_config['web']`. + + Returns: + google.oauth2.credentials.Credentials: The constructed credentials. + + Raises: + ValueError: If there is no access token in the session. + """ + client_config = client_config if client_config is not None else {} + + if not session.token: + raise ValueError( + 'There is no access token for this session, did you call ' + 'fetch_token?') + + return google.oauth2.credentials.Credentials( + session.token['access_token'], + refresh_token=session.token.get('refresh_token'), + token_uri=client_config.get('token_uri'), + client_id=client_config.get('client_id'), + client_secret=client_config.get('client_secret'), + scopes=session.scope)
diff --git a/tests/oauth2/test_flow.py b/tests/oauth2/test_flow.py index 7fc268c..e5d108f 100644 --- a/tests/oauth2/test_flow.py +++ b/tests/oauth2/test_flow.py
@@ -27,32 +27,6 @@ CLIENT_SECRETS_INFO = json.load(fh) -def test_constructor_web(): - instance = flow.Flow(CLIENT_SECRETS_INFO, scopes=mock.sentinel.scopes) - assert instance.client_config == CLIENT_SECRETS_INFO['web'] - assert (instance.oauth2session.client_id == - CLIENT_SECRETS_INFO['web']['client_id']) - assert instance.oauth2session.scope == mock.sentinel.scopes - - -def test_constructor_installed(): - info = {'installed': CLIENT_SECRETS_INFO['web']} - instance = flow.Flow(info, scopes=mock.sentinel.scopes) - assert instance.client_config == info['installed'] - assert instance.oauth2session.client_id == info['installed']['client_id'] - assert instance.oauth2session.scope == mock.sentinel.scopes - - -def test_constructor_bad_format(): - with pytest.raises(ValueError): - flow.Flow({}, scopes=[]) - - -def test_constructor_missing_keys(): - with pytest.raises(ValueError): - flow.Flow({'web': {}}, scopes=[]) - - def test_from_client_secrets_file(): instance = flow.Flow.from_client_secrets_file( CLIENT_SECRETS_FILE, scopes=mock.sentinel.scopes) @@ -62,9 +36,25 @@ assert instance.oauth2session.scope == mock.sentinel.scopes +def test_from_client_config_installed(): + client_config = {'installed': CLIENT_SECRETS_INFO['web']} + instance = flow.Flow.from_client_config( + client_config, scopes=mock.sentinel.scopes) + assert instance.client_config == client_config['installed'] + assert (instance.oauth2session.client_id == + client_config['installed']['client_id']) + assert instance.oauth2session.scope == mock.sentinel.scopes + + +def test_from_client_config_bad_format(): + with pytest.raises(ValueError): + flow.Flow.from_client_config({}, scopes=mock.sentinel.scopes) + + @pytest.fixture def instance(): - yield flow.Flow(CLIENT_SECRETS_INFO, scopes=mock.sentinel.scopes) + yield flow.Flow.from_client_config( + CLIENT_SECRETS_INFO, scopes=mock.sentinel.scopes) def test_redirect_uri(instance): @@ -123,11 +113,6 @@ assert credentials._token_uri == CLIENT_SECRETS_INFO['web']['token_uri'] -def test_bad_credentials(instance): - with pytest.raises(ValueError): - assert instance.credentials - - def test_authorized_session(instance): instance.oauth2session.token = { 'access_token': mock.sentinel.access_token,
diff --git a/tests/oauth2/test_oauthlib.py b/tests/oauth2/test_oauthlib.py new file mode 100644 index 0000000..a16c904 --- /dev/null +++ b/tests/oauth2/test_oauthlib.py
@@ -0,0 +1,92 @@ +# Copyright 2017 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import os + +import mock +import pytest + +from google.oauth2 import oauthlib + +DATA_DIR = os.path.join(os.path.dirname(__file__), '..', 'data') +CLIENT_SECRETS_FILE = os.path.join(DATA_DIR, 'client_secrets.json') + +with open(CLIENT_SECRETS_FILE, 'r') as fh: + CLIENT_SECRETS_INFO = json.load(fh) + + +def test_session_from_client_config_web(): + session, config = oauthlib.session_from_client_config( + CLIENT_SECRETS_INFO, scopes=mock.sentinel.scopes) + + assert config == CLIENT_SECRETS_INFO + assert session.client_id == CLIENT_SECRETS_INFO['web']['client_id'] + assert session.scope == mock.sentinel.scopes + + +def test_session_from_client_config_installed(): + info = {'installed': CLIENT_SECRETS_INFO['web']} + session, config = oauthlib.session_from_client_config( + info, scopes=mock.sentinel.scopes) + assert config == info + assert session.client_id == info['installed']['client_id'] + assert session.scope == mock.sentinel.scopes + + +def test_session_from_client_config_bad_format(): + with pytest.raises(ValueError): + oauthlib.session_from_client_config({}, scopes=[]) + + +def test_session_from_client_config_missing_keys(): + with pytest.raises(ValueError): + oauthlib.session_from_client_config({'web': {}}, scopes=[]) + + +def test_session_from_client_secrets_file(): + session, config = oauthlib.session_from_client_secrets_file( + CLIENT_SECRETS_FILE, scopes=mock.sentinel.scopes) + assert config == CLIENT_SECRETS_INFO + assert session.client_id == CLIENT_SECRETS_INFO['web']['client_id'] + assert session.scope == mock.sentinel.scopes + + +@pytest.fixture +def session(): + session, _ = oauthlib.session_from_client_config( + CLIENT_SECRETS_INFO, scopes=mock.sentinel.scopes) + yield session + + +def test_credentials_from_session(session): + session.token = { + 'access_token': mock.sentinel.access_token, + 'refresh_token': mock.sentinel.refresh_token + } + + credentials = oauthlib.credentials_from_session( + session, CLIENT_SECRETS_INFO['web']) + + assert credentials.token == mock.sentinel.access_token + assert credentials._refresh_token == mock.sentinel.refresh_token + assert credentials._client_id == CLIENT_SECRETS_INFO['web']['client_id'] + assert (credentials._client_secret == + CLIENT_SECRETS_INFO['web']['client_secret']) + assert credentials._token_uri == CLIENT_SECRETS_INFO['web']['token_uri'] + + +def test_bad_credentials(session): + with pytest.raises(ValueError): + oauthlib.credentials_from_session(session)